# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
# GNU General Public License for more details.
# 
# You should have received a copy of the GNU General Public License
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
#
# (C) 2012 Remek Zajac
#!/usr/bin/python

import threading
import gobject
import os, stat
import urllib2
import math
import time
import cairo

#local modules
import pyGeometry

#************************************************************************
# A representation of a cached tile file
#************************************************************************
class CachedTile(object):
    def __init__(self, x, y, zoom, cache ):
        self.x = x
        self.y = y
        self.zoom = zoom
        self.cache = cache

    def getRelativeFileDir(self):
        return str(self.zoom)+'/'+str(self.x)+'/'

    def getRelativeFileName(self):
        return str(self.y)+'.png'

    def getLocalFileDir(self):
        return self.cache.path+self.getRelativeFileDir()

    def getLocalFileName(self):
        return self.getLocalFileDir()+self.getRelativeFileName()

    def isCached(self):
        return self.cache.cacheExists(self)

    def createCache(self):
        return self.cache.createCache(self)

    def createCacheFolder(self):
        self.cache.createCacheFolder(self)

    def region(self):
        nw = OSMTilesReferenceMap.num2deg(self.x, self.y, self.zoom)
        se = OSMTilesReferenceMap.num2deg(self.x+1, self.y+1, self.zoom)
        return pyGeometry.GeoRegion((nw[0], nw[1], se[0], se[1]))

    def latlon2pix(self, latlon):
        region = self.region()
        yfactor = OSMTilesReferenceMap.ETileDimensions[1] / region.height()
        xfactor = OSMTilesReferenceMap.ETileDimensions[0] / region.width()
        x = int((latlon[1]-region.nw()[1])*xfactor)
        y = int((region.nw()[0]-latlon[0])*yfactor)
        visible = False
        if x >= 0 and x <= OSMTilesReferenceMap.ETileDimensions[0] and y >= 0 and y <= OSMTilesReferenceMap.ETileDimensions[1]:
            visible = True
        return (x,y,visible)        
        

#************************************************************************
# A representation of a cached tile file downloaded from remote location
#************************************************************************
class CachedDownloadedTile(CachedTile):
    def __init__(self, x, y, zoom, cache, url):
        super(CachedDownloadedTile,self).__init__(x, y, zoom, cache)
        self.url = url

    def getRemoteFileName(self):
        return self.url+self.getRelativeFileDir()+self.getRelativeFileName()
    

#************************************************************************
# A representation of a tile file cache
#************************************************************************
class TileCache:
    testEveryFilesAdded    = 50
    trimToPercentThershold = 80
    thresholdMB            = 50
    
    def __init__(self, path):
        self.path = path
        self.threshold = TileCache.thresholdMB*1000000
        self.walk()

    def createCacheFolder(self, tile):
        if not os.path.exists(tile.getLocalFileDir()):
            os.makedirs(tile.getLocalFileDir())        

    def createCache(self, tile):
        assert not self.cacheExists(tile)
        self.createCacheFolder(tile)
        tilefile = open(tile.getLocalFileName(), 'wb')
        self.filesAddedSinceLastTest+=1
        return tilefile
    
    def cacheExists(self, tile):
        return os.path.exists(tile.getLocalFileName())
    
    def manage(self):
        if self.filesAddedSinceLastTest > TileCache.testEveryFilesAdded:
            self.walk()

    def trimFiles(self, files, noOfFilesToTrim):
        currentTime = time.time()
        for file in sorted(files, key=lambda f: int(currentTime - os.path.getctime(f)),reverse=True):
            if noOfFilesToTrim!=0:
                os.remove(file)
                noOfFilesToTrim-=1
                continue

    def trimFilesOlderThan(self, timestamp):
        filesRemoved = 0
        for (path, dirs, files) in os.walk(self.path):
            for file in files:
                qualifiedFileName = os.path.join(path, file)
                filetimestamp = os.path.getctime(qualifiedFileName)
                if timestamp > filetimestamp:
                    os.remove(qualifiedFileName)
                    filesRemoved += 1
        if filesRemoved > 0:
            print 'A number ('+str(filesRemoved)+') of files in cache:', self.path, 'have been found stale and removed'

    def walk(self):
        folder_size = 0
        file_count = 0
        noOfFilesToRemove = 0
        print ""
        print "**Routine cache ("+self.path+") maintenance"

        allfiles = []
        for (path, dirs, files) in os.walk(self.path):
            if len(files) == 0 and len(dirs) == 0:
                #for clarity, remove an empty folder
                #os.chmod(path ,stat.S_IWRITE)
                os.rmdir(path)
                continue
            for file in files:
                qualifiedFileName = os.path.join(path, file)
                folder_size += os.path.getsize(qualifiedFileName)
                file_count += 1
                allfiles.append(qualifiedFileName)    

        if folder_size > self.threshold:
            percentLarger = int((100*folder_size / self.threshold)-100)
            noOfFilesToRemove = file_count-(file_count*self.threshold*TileCache.trimToPercentThershold/100)/folder_size
            print "Cache size:", int(folder_size/1000)/1000.0,"MB is", percentLarger,"% larger than the cache treshold:", self.threshold/1000000.0
            print "Out of the total of",file_count,"files, removing the oldest", noOfFilesToRemove
            self.trimFiles(allfiles, noOfFilesToRemove)
        else:
            print "Cache size:", int(folder_size/1000)/1000.0 , "MB, file count:", file_count,", purge threshold:", self.threshold/1000000.0,"MB"
        self.filesAddedSinceLastTest = 0


#************************************************************************
# A worker thread that can download tile files from a remote location
# to a tile file cache
#************************************************************************
class ThreadedTileDownload(threading.Thread):
    def __init__(self, tile, callback=None):
        super(ThreadedTileDownload, self).__init__()
        self.tile = tile
        self.callback = callback
        self.tilefile = self.tile.createCache()
        print ">>Start downloading: ",self.tile.getRemoteFileName()

    def run(self):
        try:
            u = urllib2.urlopen(self.tile.getRemoteFileName())
            self.tilefile.write(u.read())
        except:
            print "**Failed downloading : ",self.tile.getRemoteFileName()
            self.tilefile.close()
            os.remove(self.tile.getLocalFileName())
        else:
            print "<<Done downloading : ",self.tile.getRemoteFileName()
            self.tilefile.close()
        finally:
            if self.callback:
                gobject.idle_add(self.callback, self.tile)

    
#************************************************************************
# A base class for managing threaded generation and caching of tiles
#************************************************************************  
class TilesGeneratingEngineBase(object):
    class _TileCallback:
        def __init__(self, owner, callback):
            self.owner = owner
            self.delegated_callback = callback
        def callback(self, tile):
            self.owner.callback()
            if self.delegated_callback:
                self.delegated_callback(tile)
                
    def __init__(self):
        gobject.threads_init()
        self.downloadsSpawned = 0
        self.cache = None

    def getTileThreaded(self, tileThreadedGenerator):
        if self.downloadsSpawned == 0 and self.cache:
            self.cache.manage()
        if tileThreadedGenerator.callback:
            tileCallback = TilesGeneratingEngineBase._TileCallback(self, tileThreadedGenerator.callback)
            tileThreadedGenerator.callback = tileCallback.callback
            self.downloadsSpawned += 1
        tileThreadedGenerator.start()
        if not tileThreadedGenerator.callback:
            tileThreadedGenerator.join()
            return tileThreadedGenerator.tile
        return None

    def callback(self):
        self.downloadsSpawned -= 1
   
    
#************************************************************************
# A download and cache engine for OSM tile files
#************************************************************************       
class OSMTiles(TilesGeneratingEngineBase):
    def __init__(self):
        super(OSMTiles,self).__init__()
        self.cache = TileCache( r'./.localCache/OSMtiles/' )

    def getTile(self, x, y, zoom, callback = None):
        tile = CachedDownloadedTile(x,y,zoom, self.cache, (r'http://a.tile.openstreetmap.org/'))
        if tile.isCached():
            return tile
        threadedDownload = ThreadedTileDownload(tile, callback)
        return self.getTileThreaded(threadedDownload)



#************************************************************************
# A displayed tile (loads file into a cairo surface)
#************************************************************************ 
class DisplayedTile:
    def __init__(self):
        self.surface = None
        self.valid = True
    def load(self, tile):
        if os.path.exists(tile.getLocalFileName()):
            try:
                self.surface = cairo.ImageSurface.create_from_png(tile.getLocalFileName())
            except:
                print "**Could not read image from file:", tile.getLocalFileName()
        else:
            print "**File not found:", tile.getLocalFileName()
            
    def validate(self):
        self.valid = True
    def invalidate(self):
        self.valid = False
    def isValid(self):
        return self.valid

#************************************************************************
# A displayed tile set
#************************************************************************ 
class DisplayedTileSet:
    def __init__(self):
        self.tiles = {}
    def add(self, key):
        tile = DisplayedTile()
        self.tiles[key] = tile
        return tile
    def invalidate(self):
        for key, tile in self.tiles.items():
            tile.invalidate()
    def clearInvalid(self):
        for key, tile in self.tiles.items():
            if not tile.isValid():
                del self.tiles[key]
    def get(self, key):
        if self.tiles.has_key(key):
            return self.tiles[key]
        return None
    def has_key(self, key):
        return self.tiles.has_key(key)
    def count(self):
        return len(self.tiles)
    def clearAll(self):
        self.tiles = {}


#************************************************************************
# A widget engine that serves as a reference for all OSM-derived tilesets.
# It provides calibration data and function for mapping tiles, zooms and coordinates.
#************************************************************************ 
class OSMTilesReferenceMap:
    ETileDimensions = (256, 256)
    def __init__(self, parent, latlon, zoom):
        self.latlonCentre = latlon
        self.zoom = zoom
        self.parent = parent
        self.firstTileX, self.firstTileY = (-1,-1)        

    def latlon2pix(self, latlon):
        x = int((latlon[1]-self.latlonCentre[1])*self.xfactor)+(self.parent.allocation.width/2)
        y = int((self.latlonCentre[0]-latlon[0])*self.yfactor)+(self.parent.allocation.height/2)
        visible = False
        if x >= 0 and x <= self.parent.allocation.width and y >= 0 and y <= self.parent.allocation.height:
            visible = True
        return (x,y,visible)

    def pix2latlon(self, pixxy):
        lat = self.latlonCentre[0]-1.0*((pixxy[1]-(self.parent.allocation.height/2))/self.yfactor)
        lon = self.latlonCentre[1]+1.0*((pixxy[0]-(self.parent.allocation.width/2))/self.xfactor)
        return (lat, lon)
    
    def invalidate(self, calledBy):
        if calledBy != self.parent and calledBy != self:
            self.parent.invalidate(self)
            return
        centreTile = OSMTilesReferenceMap.deg2num(self.latlonCentre[0], self.latlonCentre[1], self.zoom)
        centreTileLatLon = OSMTilesReferenceMap.num2deg(centreTile[0],centreTile[1],self.zoom)       
        nextTileLatLon = OSMTilesReferenceMap.num2deg(centreTile[0]+1,centreTile[1]+1,self.zoom)       
        tileLatDelta = math.fabs(centreTileLatLon[0]-nextTileLatLon[0])
        tileLonDelta = math.fabs(centreTileLatLon[1]-nextTileLatLon[1])
        self.yfactor = OSMTilesReferenceMap.ETileDimensions[1] / tileLatDelta
        self.xfactor = OSMTilesReferenceMap.ETileDimensions[0] / tileLonDelta
        latlonNW = self.pix2latlon((0,0))
        self.firstTileX, self.firstTileY = OSMTilesReferenceMap.deg2num(latlonNW[0], latlonNW[1], self.zoom)
        latlonNWFirstTile = OSMTilesReferenceMap.num2deg(self.firstTileX, self.firstTileY,self.zoom)
        self.imagePixOffset = self.latlon2pix((latlonNWFirstTile[0],latlonNWFirstTile[1]))
        self.queue_draw()

    def allocation(self):
        return self.parent.allocation

    def queue_draw(self):
        return self.parent.queue_draw()

    def drawingToolkit(self):
        return self.parent.drawingToolkit()

    #the method returns the smallest zoom the supplied longitudal range of a single tile will entirely fit
    @staticmethod
    def londelta2zoom(londelta):
        zoom =math.log(360.0/londelta)/math.log(2)
        return int(zoom)

    #the method returns the smallest zoom the supplied latitudal range of a single tile will entirely fit
    @staticmethod
    def latdelta2zoom(latdelta):
        latdeltaradians = math.radians(latdelta)
        logexpression = math.log(math.tan(latdeltaradians)+(1.0/math.cos(latdeltaradians)))
        zoom = math.log(math.pi/logexpression)/math.log(2)+1
        return int(zoom)

    @staticmethod
    def deg2num(lat_deg, lon_deg, zoom):
        lat_rad = math.radians(lat_deg)
        n = 2.0 ** zoom
        xtile = int((lon_deg + 180.0) / 360.0 * n)
        ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
        return (xtile, ytile)

    @staticmethod
    def num2deg(xtile, ytile, zoom):
        n = 2.0 ** zoom
        lon_deg = xtile / n * 360.0 - 180.0
        lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
        lat_deg = math.degrees(lat_rad)
        return (lat_deg, lon_deg)      

    def showRegion(self, region):
        tilesWidth = (self.parent.allocation.width/OSMTilesReferenceMap.ETileDimensions[0])+2
        tilesHeight = (self.parent.allocation.height/OSMTilesReferenceMap.ETileDimensions[1])+2            
        tilesLatHeigh = region.height()/tilesHeight
        tilesLonWidth = region.width()/tilesWidth

        lonzoom = self.londelta2zoom(tilesLonWidth)
        latzoom = self.latdelta2zoom(tilesLatHeigh)
        self.panAndZoom(region.centre(), min(lonzoom, latzoom, 17))
        self.parent.invalidate(self)

    def panAndZoom(self, latlon, zoom = None):
        self.latlonCentre = latlon
        if zoom:
            self.zoom = zoom

    def config(self):
        return parent.config()

    
#************************************************************************
# A tile map display. Implements draw() by requesting tiles from
# tileProductionEngine and rendering them in reference to the coordinate
# system served by the parent
#************************************************************************ 
class OSMTilesMap:
    def __init__(self, parent, tileProductionEngine):
        self.parent = parent        
        self.tiles = DisplayedTileSet()
        self.tileEngine = tileProductionEngine
        
    def tile_event(self, tile):
        requestedTile =  self.tiles.get((tile.x, tile.y))
        if requestedTile:
            requestedTile.load(tile)
        self.parent.queue_draw()

    def draw(self, cr):
        tilesWidth = (self.parent.allocation().width/OSMTilesReferenceMap.ETileDimensions[0])+2
        tilesHeight = (self.parent.allocation().height/OSMTilesReferenceMap.ETileDimensions[1])+2
        for x in range (0,tilesWidth):
            for y in range (0,tilesHeight):
                requestedTile = self.tiles.get((x+self.parent.firstTileX, y+self.parent.firstTileY))
                if requestedTile:
                    #tile has been requested
                    if requestedTile.surface:
                        #tile has been downloaded
                        pastex = x*OSMTilesReferenceMap.ETileDimensions[0]+self.parent.imagePixOffset[0]
                        pastey = y*OSMTilesReferenceMap.ETileDimensions[1]+self.parent.imagePixOffset[1]
                        cr.set_source_surface(requestedTile.surface, pastex, pastey)
                        cr.paint()
                        #cr.set_source_surface(requestedTile.surface, pastex+10, pastey+10)
                        #cr.paint_with_alpha(0.5)
                else:
                    self.invalidate(self)
                    break

    def invalidate(self, calledBy):
        tilesWidth = (self.parent.allocation().width/OSMTilesReferenceMap.ETileDimensions[0])+2
        tilesHeight = (self.parent.allocation().height/OSMTilesReferenceMap.ETileDimensions[1])+2
        self.tiles.invalidate()
        for x in range (0,tilesWidth):
            for y in range (0,tilesHeight):
                requestedTile = self.tiles.get((x+self.parent.firstTileX, y+self.parent.firstTileY))
                if not requestedTile:
                    #if tile not already requested
                    cachedTile = self.tileEngine.getTile(x+self.parent.firstTileX, y+self.parent.firstTileY, self.parent.zoom, self.tile_event)
                    requestedTile = self.tiles.add((x+self.parent.firstTileX, y+self.parent.firstTileY))
                    if cachedTile:
                        requestedTile.load(cachedTile)
                else:
                    requestedTile.validate()
        self.tiles.clearInvalid()
        self.parent.queue_draw()
  
    
        


